• 简易的光栅化渲染器

    本文是一个完整的图形学入门实践课程,目前还在更新中,GitHub已开源。理论上本文项目需要20-30个小时完成。不知道为啥我的网站统计字数也有问题。

    主要内容是完全手撸一个光栅化渲染器。本文会从头复习图形学以及C++的相关知识,包括从零构造向量模版库、光栅化原理解释、图形学相关基础算法解释等等内容。

    另外原作者的的透视矩阵部分是经过一定程度的简化的,与虎书等正统做法不同。我会先按照原文ssloy老师的思想表达关键内容,最后按照我的想法完善本文。并且,原项目中的数学向量矩阵库写得不是很好,我专门开了一章一步步重构这个库。

    原项目链接:https://github.com/ssloy/tinyrenderer

    本项目链接:https://github.com/Remyuu/Tiny-Renderer

    简易的光栅化渲染器0 简单的开始1.1 画线第一关:实现画线第二关:发现BUG第三关:解决BUG第四关:优化前言第五关:Bresenham's 优化第六关:注意流水线预测第七关:浮点数整型化1.2 三维画线第一关:加载.obj第二关:绘制第三关:优化2.1 三角形光栅化第一关:线框三角形第二关:请你自己画实心的三角形第三关:扫描线算法第四关:包围盒逐点扫描第五关:重心坐标2.2 平面着色Flat shading render第一关:回顾第二关:绘制随机的颜色第三关:根据光线传播绘制颜色3.1 表面剔除第一关:画家算法(Painters' Algorithm)第二关:了解z-buffer第三关:创建Z-Buffer第四关:整理当前代码3.2 上贴图第一关:思路第二关:加载纹理文件第三关:获取纹理坐标第四关:通过纹理坐标uv获取对应颜色第五关:在光栅化三角形函数中增加贴贴图的功能第六关:为模板函数添加更多重载符号操作4.1 透视视角第一关:线性变换第二关:齐次坐标 Homogeneous coordinates第三关:三维世界第四关:具体代码实现4.2 项目代码分析第一关:model类第二关:geometry第三关:main5.1 移动摄像机第一关:定义摄像机第二关:相机代码6.1 优化/重写代码6.2 重写模版向量类第一关:需求分析第二关:实现Vec2模版以及四个算数符第三关:实现Vec3模版以及四个算数符第四关:用模版构建不同大小的向量第五关:进一步完善向量功能第六关:构建矩阵第七关:继续完善矩阵库6.3 整合光栅化代码特别节目1之:main代码之旅特别节目2之:细说GouraudShader特别节目3之:开始绘画-片元着色器🌈 彩虹着色器📺 模拟老电视效果🔥 火焰效果🌌 星空效果6.4 升级Shader-支持UV纹理🎬 Shader类的角色列表🎭 vertex函数:多面手🌈 fragment函数:艺术家🎨 那么,这个Shader类都能做什么?6.5 学习法线贴图第一关:纹理第二关:全局坐标系与Darboux坐标系第三关:经常见到的Uniform第四关:光照计算6.6 实现Phong模型7.1 阴影第一关:目前的问题第二关:第一趟渲染-从光源出发第三关:第二趟渲染-从主相机出发8.1 环境光遮蔽 - 模拟全局光照效果特别节目:知识脉络第一关:啥是AO?如何结合Phong使用?第二关:做梦第三关:屏幕空间环境遮挡 (SSAO)附录1. c++模版类 - 从入门到入土第一关:为什么需要模版类?第二关:「函数模版」第三关:「类模版」第四关:「多模板参数」与「非类型参数」第五关:「模板特化」第六关:「类型推断」1. auto & decltype2. 模板中的基本类型推断3. 自动构造模版类型4. 尾返回类型第七关:「变量模板」第八关:「模板类型别名」第九关:模板的SFINAE原则第十关:模板与友元第十一关:折叠表达式第十二关:模板概念(Concepts) - C++20第十三关: std::enable_if 和 SFINAE第十四关:类模板偏特化第十五关:constexpr 和模板第十六关:模板中的嵌套类型第十七关:模板参数包与展开第十八关:Lambda 表达式与模板第十九关:模板递归第二十关:带有模板的继承1. 模版基类2. 模版子类3. 在模板类中继承模板基类第二十一关:std::type_trait的工具集第二十二关:模板与动态多态性备注/声明

    0 简单的开始

    五星上将曾经说过,懂的越少,懂的越多。我接下来将提供一个tgaimage的模块,你说要不要仔细研究研究?我的评价是不需要,如学。毕竟懂的越多,懂的越少。

    在这里提供一个最基础的框架🔗,他只包含了tgaimage模块。该模块主要用于生成.TGA文件。以下是一个最基本的框架代码:

    上面代码会创建一个100*100的image图像,并且以tga的格式保存在硬盘中。我们在TODO中添加代码:

    代码作用是在(1, 1)的位置将像素设置为红色。output.tga的图像大概如下所示:

    0.0

    1.1 画线

    这一章节的目标是画线。具体而言是制作一个函数,传入两个点,在屏幕上绘制线段。

    第一关:实现画线

    给定空间中的两个点,在两点(x0, y0)(x1, y1)之间绘制线段。

    最简单的代码如下:

    image-20230830171306158

    第二关:发现BUG

    上面代码中的.01其实是错误的。不同的分辨率对应的绘制步长肯定不一样,太大的步长会导致:

    image-20230830171714587

    所以我们的逻辑应该是:需要画多少像素点就循环Draw多少次。最简单的想法可能是绘制x1-x0个像素或者是y1-y0个像素:

    上面代码是最简单的插值计算。但是这个算法是错误的。画三条线:

    image-20230830172534739

    白色线看起来非常好,红色线看起来断断续续的,蓝色线直接看不见了。于是总结出以下两个问题:

    1. 理论上说白色线和蓝色线应该是同一条线,只是起点与终点不同

    2. 太“陡峭”的线效果不对

    接下来就解决这个两个问题。

    此处“陡峭”的意思是(y1-y0)>(x1-x0)

    下文“平缓”的意思是(y1-y0)<(x1-x0)

    第三关:解决BUG

    为了解决起点终点顺序不同导致的问题,只需要在算法开始时判断两点x分量的大小:

    为了画出没有空隙的“陡峭”线,只需要将“陡峭”的线变成“平缓”的线。最终的代码:

    image-20230830174932047

    如果你想测试你自己的代码是否正确,可以尝试绘制出以下的线段:

    image-20230831145010708

    第四关:优化前言

    目前为止,代码运行得非常顺利,并且具备良好的可读性与精简度。但是,画线作为渲染器最基础的操作,我们需要确保其足够高效。

    性能优化是一个非常复杂且系统的问题。在优化之前需要明确优化的平台和硬件。在GPU上优化和CPU上优化是完全不同的。我的CPU是Apple Silicon M1 pro,我尝试绘制了9,000,000条线段。

    发现在line()函数内,image.set();函数占用时间比率是38.25%,构建TGAColor对象是19.75%,14%左右的时间花在内存拷贝上,剩下的25%左右的时间花费则是我们需要优化的部分。下面的内容我将以运行时间作为测试指标。

    第五关:Bresenham's 优化

    我们注意到,for循环中的除法操作是不变的,因此我们可以将除法放到for循环外面。并且通过斜率估计每向前走一步,另一个轴的增量error。dError是一个误差积累,一旦误差积累大于半个像素(0.5),就对像素进行一次修正。

    没有优化用时:2.98s

    第一次优化用时:2.96s

    第六关:注意流水线预测

    在很多教程当中,为了方便修改,会用一些trick将“陡峭”的线和“平缓”的线的for循环代码整合到一起。即先将“陡峭”线两点的xy互换,最后再image.set()的时候再换回来。

    没有优化用时:2.98s

    第一次优化用时:2.96s

    合并分支用时:3.22s

    惊奇地发现,竟然有很大的性能下降!背后的原因之一写在了这一小节的标题中。这是一种刚刚我们的操作增加了控制冒险(Control Hazard)。合并分支后的代码每一次for循环都有一个分支,可能导致流水线冒险。这是现代处理器由于预测错误的分支而导致的性能下降。而第一段代码中for循环没有分支,分支预测可能会更准确。

    简而言之,减少for循环中的分支对性能的提升帮助非常大!

    值得一提的是,如果在Tiny-Renderer中使用本文的操作,速度将会进一步提升。这在Issues中也有相应讨论:链接🔗

    第七关:浮点数整型化

    为什么我们必须用浮点数呢?在循环中我们只在与0.5做比较的时候用到了。因此我们完全可以将error乘个2再乘个dx(或dy),将其完全转化为int。

    没有优化用时:2.98s

    第一次优化用时:2.96s

    合并分支用时:3.22s

    第二次优化用时:2.96s

    优化程度也较为有限了,原因是在浮点数化整的过程中增加了计算的次数,与浮点数的计算压力相抵消了。

    1.2 三维画线

    在前面的内容中,我们完成了Line()函数的编写。具体内容是给定屏幕坐标上的两个点就可以在屏幕中绘制线段。

    第一关:加载.obj

    首先,我们创建model类作为物体对象。我们在model加载的.obj文件里可能会有如下内容:

    v表示3D坐标,后面通常是三个浮点数,分别对应空间中的x, y, z。上面例子代表一个顶点,其坐标为 (1.0, 2.0, 3.0)

    当定义一个面(f)时,你引用的是先前定义的顶点(v)的索引。

    上面两行都表示一个面,

    在这里我提供一个简单的 .obj 文件解析器 model.cpp 。你可以在此处找到当前项目链接🔗。以下是你可能用到的model类的信息:

    本项目使用的.obj文件的所有顶点数据已做归一化,也就是说v后面的三个数字都是在[-1, 1]之间。

    第二关:绘制

    在这里我们仅仅考虑三维顶点中的(x, y),不考虑深度值。最终在main.cpp中通过model解析出来的顶点坐标绘制出所有线框即可。

    这段代码对所有的面进行迭代,将每个面的三条边都进行绘制。

    image-20230831145239967

    第三关:优化

    将不必要的计算设置为const,避免重复分配释放内存。

    2.1 三角形光栅化

    接下来,绘制完整的三角形,不光是一个个三角形线框,更是要一个实心的三角形!为什么是三角形而不是其他形状比如四边形?因为三角形可以任意组合成为所有其他的形状。基本上,在OpenGL中绝大多数都是三角形,因此我们的渲染器暂时无需考虑其他的东西了。

    当绘制完一个实心的三角形后,完整渲染一个模型也就不算难事了。

    在Games101的作业中,我们使用了AABB包围盒与判断点是否在三角形内的方法对三角形光栅化。你完全可以用自己的算法绘制三角形,在本文中,我们使用割半法处理。

    第一关:线框三角形

    利用上一章节完成的line()函数,进一步将其包装成绘制三角形线框的triangleLine()函数。

    image-20230831145749059

    第二关:请你自己画实心的三角形

    这一部分最好由你自己花费大约一个小时完成。一个好的三角形光栅化算法应该是简洁且高效的。你目前的项目大概是这样的:链接🔗

    【此处省略一小时】

    image-20230831213131128

    第三关:扫描线算法

    当你完成了你的算法之后,不妨来看看其他人是怎么做的。为了光栅化一个实心三角形,一种非常常见的方法是使用扫描线算法:

    1. v(或 y)坐标对三角形的三个顶点进行排序,使得 v0 是最低的,v2 是最高的。

    2. 对于三角形的每一行(从 v0.vv2.v),确定该行与三角形的两边的交点,并绘制一条从左交点到右交点的线。

    第四关:包围盒逐点扫描

    介绍另一个非常有名的方法,包围盒扫描方法。将需要光栅化的三角形框上一个矩形的包围盒子内,在这个包围盒子内逐个像素判断该像素是否在三角形内。如果在三角形内,则绘制对应的像素;如果在三角形外,则略过。伪代码如下:

    想要实现这个方法,主要需要解决两个问题:找到包围盒、判断某个像素点是否在三角形内。

    第一个问题很好解决,找到三角形的三个点中最小和最大的两个分量两两组合。

    第二个问题似乎有些棘手。我们需要学习什么是重心坐标 (barycentric coordinates )。

    第五关:重心坐标

    利用重心坐标,可以判断给定某个点与三角形之间的位置关系。

    给定一个三角形ABC和任意一个点P (x,y) ,这个点的坐标都可以用点ABC线性表示。不理解也无所谓,简单理解就是一个点P和三角形三点的关系可以用三个数字来表示,像下面公式这样:

    P=(1uv)A+uB+vC

    我们把上面的式子解开,得到关于 AB,ACAP的关系:

    P=A+uAB+vAC

    然后将点P挪到同一边,得到下面的式子:

    uAB+vAC+PA=0

    然后将上面的向量分为x分量与y分量,写成两个等式。接下来用矩阵表示他们:

    {[uv1][ABxACxPAx]=0[uv1][AByACyPAy]=0

    两个向量点积是0,说明两个向量垂直。右边这俩向量都与 [uv1] ,说明他们的叉积就是k[uv1] ,因此轻轻松松解出uv。

    梳理一下,当务之急是判断给定的一个点与一个三角形的关系。直接给出结论,如果点在三角形内部,则这三个系数都属于(0,1)之间。直接给出光栅化一个三角形的代码:

    barycentric()函数可能比较难理解,可以暂时抛弃研究其数学原理。并且上面这段代码是经过优化的,如果希望了解其原理可以看我这一篇文章:链接🔗

    image-20230904005910991

    你可以在下面的链接中找到当前项目的代码:链接🔗

    2.2 平面着色Flat shading render

    在「1.2 三维画线」中绘制了模型的线框,即空三角形模型。在「2.1 三角形光栅化」中,介绍了两种方法绘制一个“实心”的三角形。现在,我们将使用“平面着色”来渲染小人模型,其中平面着色使用随机的RGB数值。

    第一关:回顾

    首先将加载模型的相关代码准备好:

    第二关:绘制随机的颜色

    下面是遍历获得模型的每一个需要绘制的三角形的代码:

    当我们获得了所有的面,在每一趟遍历中,将face的三个点取出来并转换到屏幕坐标上,最后传给三角形光栅化函数:

    image-20230904134928856

    第三关:根据光线传播绘制颜色

    刚才的随机颜色远远满足不了我们,现在我们根据光线与三角形的法线方向绘制不同的灰度。什么意思呢?看下面这张图,当物体表面的法线方向与光线方向垂直,物体接受到了最多的光;随着法线与光线方向的夹角越来越大,收到光的照射也会越来越少。当法线与光线方向垂直的时候,表面就接收不到光线了。

    image-20230904135449781

    将这个特性添加到光栅化渲染器中。

    上面代码需要注意的点:

    intensity小于等于0的意思是这个面(三角形)背对着光线,摄像机肯定看不到,不需要绘制。

    image-20230904141708453

    注意到嘴巴的地方有些问题,本应在嘴唇后面的嘴巴内部区域(像口腔这样的空腔)却被画在嘴唇的上方或前面。这表明我们对不可见三角形的处理方式不够精确或不够规范。“dirty clipping”方法只适用于凸形状。对于凹形状或其他复杂的形状,该方法可能会导致错误。在下一章节中我们使用 z-buffer 解决这个瑕疵(渲染错误)。

    这里给出当前步骤的代码链接🔗

    3.1 表面剔除

    上一章的末尾我们发现嘴巴部分的渲染出现了错误。本章先介绍画家算法(Painters' Algorithm),随后引出 Z-Buffer ,插值计算出需渲染的像素的深度值。

    第一关:画家算法(Painters' Algorithm)

    这个算法很直接,将物体按其到观察者的距离排序,然后从远到近的顺序绘制,这样近处的物体自然会覆盖掉远处的物体。

    但是仔细想就会发现一个问题,当物体相互阻挡时算法就会出错。也就是说,画家算法无法处理相互重叠的多边形。

    image-20230904144410471

    第二关:了解z-buffer

    如果画家算法行不通,应该怎么解决物体相互重叠的问题呢?我们初始化一张表,长宽与屏幕像素匹配,且每个像素大小初始化为无限远。每一个像素存储一个深度值。当要渲染一个三角形的一个像素时,先比较当前欲渲染的像素位置与表中对应的深度值,如果当前欲渲染的像素深度比较浅,说明欲渲染的像素更靠近屏幕,因此渲染。

    而这张表,我们称之为:Z-Buffer。

    第三关:创建Z-Buffer

    理论上说创建的这个 Z-Buffer 是一个二维的数组,例如:

    但是,我认为这太丑陋了,不符合我的审美。我的做法是将二维数组打包变成一个一维的数组:

    最基本的数据结构,取用的时候只需要:

    初始化zBuffer可以用一行代码解决,将其全部设置为负无穷:

    第四关:整理当前代码

    要给当前的triangleRaster()函数新增 Z-Buffer 功能。

    我们给pixel增加一个维度用于存储深度值。另外,由于深度是float类型,如果沿用之前的函数可能会出现问题,原因是之前传入的顶点都是经过取舍得到的整数且不包含深度信息。而且需要注意整数坐标下的深度值往往不等于取舍之前的深度值,这个精度的损失带来的问题是在复杂精细且深度值波动很大的位置会出现渲染错误。但是目前可以直接忽略,等到后面进行超采样、抗锯齿或者其他需要考虑像素内部细节的技术时再展开讲解。

    因此,为了后期拓展的方便,我们将之前涉及pixel的Vec2i代码换为Vec3f类型,并且每一个点都增加一个维度用于存储深度值。

    将世界坐标转化到屏幕坐标的函数打包:

    另外,对tgaimage、model和geometry做了一些修改,主要是优化了一些细节。具体项目请查看当前项目分支链接🔗

    image-20230904191612606

    3.2 上贴图

    啥是贴图呢?就是类似这种奇奇怪怪的图片。

    image-20230905174124334

    目前我们已经完成了三角形的重心坐标插值得出了三角形内某点的深度值。接下来我们还可以用插值操作计算对应的纹理坐标。

    本章基于「3.1 表面剔除」最后的项目完善,本章主要是c++ STL相关操作。

    第一关:思路

    请首先下载「3.1 表面剔除」最后的项目链接🔗

    首先从硬盘中加载纹理贴图,然后传到三角形顶点处,通过对应的纹理坐标从texture获取颜色,最后插值得到各个像素的颜色。

    另外,项目框架的代办清单:

    1. 增加model模块中对vt标签的解析

    2. 完善model模块中对f标签的解析,具体是获取纹理坐标索引

    3. 完善geometry模块的操作符,具体是实现Vec<Dom, f>与float相乘等操作

    第二关:加载纹理文件

    从硬盘中加载纹理texture,用TGAImage存储。

    第三关:获取纹理坐标

    在 model.h 中,在class Model上方创建一个Face结构体用于存储解析后obj中的f标签。f标签有三个值,这里只存储前两个。f标签的三个值分别是顶点索引/纹理索引/法线索引,等后面用到了法线坐标再拓展即可。

    然后将model的模版私有属性:

    改为:

    同时也修改 model.cpp 下获取 face 的函数:

    实际解析时的函数:

    接下来解析纹理坐标索引texcoords_。

    最后就可以通过对应的索引得到纹理坐标了。

    第四关:通过纹理坐标uv获取对应颜色

    获得了纹理坐标后就可以用texture.get(x_pos, y_pos)获取图片(贴图/纹理)的对应像素。注意最后TGAColor使用的是BGRA通道,而不是RGBA通道。

    第五关:在光栅化三角形函数中增加贴贴图的功能

    增加了四个传参,分别是三个三角形的纹理坐标与纹理。实现细节直接看代码比较直接。

    在上面的代码中,你可能会发现乘号竟然报错了,这个问题在下一关马上得到解决。最终在 main() 函数中这样调用:

    image-20230905084358296

    第六关:为模板函数添加更多重载符号操作

    在写纹理坐标的时候,我们会用到一些操作比如说 Vec2i 类型与 float 浮点数相乘和相除。将下面的代码添加到 geometry.h 的中间部分:

    这样就完全没问题了,大功告成。当然你也可以在这个链接🔗中找到完整的代码。

    4.1 透视视角

    上文的内容全部都是正交视角下的渲染,这显然算不上酷,因为我们仅仅是将z轴“拍扁”了。这一章节的目标是学习绘制透视视角。

    image-20230409155021065

    https://stackoverflow.com/questions/36573283/from-perspective-picture-to-orthographic-picture

    第一关:线性变换

    缩放可以表示为:

    scale(sx,sy)=[sx00sy].

    image-20230408154330557

    拉伸可以表示为:

    shear-x(s)=[1s01],shear-y(s)=[10s1]

    image-20230408154937046

    旋转可以表示为:

    Rθ=[cosθsinθsinθcosθ]

    image-20230408155212728

    第二关:齐次坐标 Homogeneous coordinates

    为什么要引入齐次坐标呢?因为想要表示一个二维变换的平移并不能仅仅使用一个2x2的矩阵。平移并不在这个二维矩阵的线性空间中。因此,我们拓展一个维度帮助我们表示平移。

    在计算机图形学中我们使用齐次坐标(Homogeneous Coord)。比如说一个二维的(x,y)使用平移矩阵变换到(x,y)

    (xyw)=(10tx01ty001)(xy1)=(x+txy+ty1)

    这样,我们就可以通过 tx,ty 做平移变换,简直太聪明了。

    在常规的笛卡尔坐标中,很难从数学表示上区分一个点和一个向量,因为它们都可能使用相同的形式如 vec2(x,y)。但在齐次坐标中,通过最后一个坐标值(这里的z)可以明确区分它们。当z=0时,它是一个向量;当z≠0时,它是一个点。较为数学一点的表示方法:

    k[xy1],k0

    上面公式中,无论 k 取多少,都表示同一个点。再举个例子:

    [xy1][2x2y2][514x514y514][114x114y114]

    齐次坐标是一个大大的好啊,当你进行数学操作时,结果的类型(向量或点)是明确的:

    这使得数学操作更加直观和有意义。

    一段来自屏幕外的声音🔊:齐次坐标最下面那行有啥用??这个问题非常关键。

    [abmcdnpq1]

    家喻户晓的,[abcd]可以实现缩放,[mn1] 可以实现平移。

    但是,这个 [pq] 能干嘛?

    变换矩阵不做其他线性变换,仅仅将pq随便设为一个数:

    [100010201][xy1]=[xy2x+1][x2x+1y2x+11]

    我们发现,这个变换有点奇怪。随着(x,y)越来越大,这个“缩放因子”就会越来越小。

    有没有一种可能,这个就是近大远小?

    image-20230905160328502

    没错,这就是一种透视的现象。至此,上面齐次坐标矩阵的最后一朵乌云已经攻破。 [pq] 就是用来做透视变换的。

    随着最后一朵乌云散去,必然会迎来更多的乌云。新的乌云,名字叫做三维。

    第三关:三维世界

    上文所述都是二维下的,现在进入三维的世界。三维的齐次坐标自然就是用四维的矩阵表示。

    缩放:

    S(sx,sy,sz)=(sx0000sy0000sz00001)

    平移:

    T(tx,ty,tz)=(100tx010ty001tz0001)

    绕x,z,y轴旋转:

    {Rx(α)=(10000cosαsinα00sinαcosα00001)Rz(α)=(cosαsinα00sinαcosα0000100001)Ry(α)=(cosα0sinα00100sinα0cosα00001)

    透视:

    P(r)=(10000100001000r1)

    为什么只有z方向上才有r?因为我们默认摄像机摆在z轴,物体随着z轴透视缩放的。

    现在,将一个三维坐标通过透视缩放,得到:

    [10000100001000r1][xyz1]=[xyz1+zr][x1+zry1+zrz1+zr1]

    image-20230905173407444

    上图中,横轴向左是z的正方向,纵轴向上是y的正方向。

    根据相似三角形法则,y1/By=(c-z1)/c,最后得到:

    r=1c

    因此得到透视矩阵:

    [100001000010001c1]

    大家可能发现,如果有接触过图形学的朋友们可能会对之前的学习产生怀疑。为什么这里顶点变换的透视变换矩阵和其他教材都不一样呢?比方说虎书上的透视矩阵是这样的:

    [1 aspect ×tan(fovy2)00001tan( fovy 2)0000 far + near  far  near 2× far × near  far  near 0010]

    所以我们刚才推导出来的矩阵并不是常见的透视投影矩阵,但是他确实表达了投影的思想,因此我们暂时用着。

    来自屏幕外的声音🔊:停停停,理论说了这么多,能不能搞点实践的!

    第四关:具体代码实现

    在上一关中,我们得到了不那么正规但是能用的透视矩阵,现在要做的就是将世界坐标的顶点转换到齐次坐标,然后乘上透视矩阵、视口矩阵。视口矩阵其实就是用一个简洁的矩阵把下面归一化设备坐标 NDC [-1,1]转换到了屏幕空间[0,width]。看下面这一段代码就是那个被ViewPort矩阵淘汰的家伙:

    接下来,把顶点坐标乘上我们下面两个矩阵(顺序要注意):

    一段来自屏幕外的声音🔊:等等等等,v2m和m2v是什么?viewport()具体实现方法是什么?

    v2m是将向量变成矩阵(齐次坐标),m2v反之。

    然后还需要完善geometry的模块,在geometry.h中添加如下代码:

    然后添加文件 geometry.cpp

    接下来,渲染器启动!

    image-20230906112817473

    来自甲方的声音🔊:效果非常好,下次不要做了。

    看得出来,画面出现了一点问题。但是值得注意的是,顶点的位置已经基本正确了。但是贴图出现了错误。

    借此机会,调整一下贴图加载的逻辑。我们原先在main函数粗暴加载,现在我们将物体对应的贴图当作model对象的一个属性,自动读取。在model.h中加入字段:

    构造函数就可以根据文件名字存入对应的贴图了:

    然后通过以下函数得到对应的uv坐标:

    改动部分比较多直接阅读项目吧,项目链接🔗在这里,我们在「4.2 代码分析」中详细讨论整个项目,力求搞懂每一行代码与设计思路,尤其是C++ STL细节。下面是一个最终结果:

    image-20230906183206037

    4.2 项目代码分析

    目前的代码链接🔗有较大的改动,但是技术原理是不变的。本章节可以选择性阅读,也可以直接跳到「5.1 移动摄像机」。

    项目结构:

    第一关:model类

    model.h

    Model.cpp

    第二关:geometry

    geometry.h中,分为两个部分:模版向量类,矩阵类

    geometry.cpp 大同小异,不做笔记了

    注意到代码中用到了remplate <> template <>进行模版特化,这种用法我比较少见到,也看不懂。

    假设现在有一个模版类Vec2,有一个函数模版printType

    现在,要为Vec2<float>特化printType方法,让他在int的时候打印int的专属信息。

    测试:

    第三关:main

    5.1 移动摄像机

    能看到这个地方的朋友简直太牛逼了,这一章就稍微有点简单了。

    在初中我们就学过,物体的运动是相对的。

    摄像机向左移动,其实就是物体向右移动。我们可以让摄像机保持不动,让物体运动(平移)。

    旋转其实也是一样的,假设摄像机绕着垂直的y轴顺时针旋转了45度,相当于物体逆时针旋转了45度。

    图形学中我们知道,一个变换就代表了一个矩阵。并且在线性代数中我们知道,一个矩阵A乘上另一个矩阵B之后,想要变回原来的矩阵A,只需要再乘上矩阵B的逆。也就是说,要反变换,只需要乘上对应变换的逆即可。

    第一关:定义摄像机

    我们想,需要定义什么量才可以确保摄像机在同一时间拍摄的画面是一模一样的?(这里只考虑摄像机的位置、方向,摄像机镜头、焦距、ISO等信息默认相等。)

    很显然可以想到的是摄像机的位置,我们将其定义为cameraPos

    并且拍摄的方向也需要定义,我们就叫他gazeDirection

    大家应该都玩过Rainbow Six围攻或者是PUBG吧,他们都有一个共同的特点:可以歪头射击。即使是在同一个摄像机位置,同一个注视方向,也不能确保拍摄画面的唯一性。因此,我们定义一个「向上向量」,即viewUp

    拓展一下,「向上向量」的很多好处:

    1. 当使用欧拉角定义摄像机旋转时,万向节锁是一个常见问题,它会导致摄像机失去一个自由度的旋转。使用向上向量和观察向量可以防止这个问题,因为它们定义了一个明确的摄像机方向和旋转。

    2. 有了向上向量,我们可以方便地使用叉积计算摄像机的右向量,这对于某些计算和操作非常有用。

    ok,完事之后,我们开始写代码。

    第二关:相机代码

    在上一关我们得到了一个相机的基本定义:位置、看向的方向以及向上向量。

    但是「看向的方向」这个量不太直接,我们想要更直接的也就是摄像机想要看向哪里,于是相机的代码中,定义相机的三要素就是:位置(cameraPos),看向的位置(lookAt)以及向上向量(viewUp)。

    阅读下面代码之前需要知道我们注视的方向是 -z 轴。

    image-20230907165837308

    6.1 优化/重写代码

    首先继续优化 geometry 类,相当于重写一个自己的向量类。接着将当前main函数的一系列关于光栅化三角形的代码整合到新的类中。

    由于本章的每一关内容都很多,尤其是重写模版向量类,因此我将内容较多的关卡提高了标题层次。

    6.2 重写模版向量类

    第一关:需求分析

    不懂c++模版类的读者可以阅读 附件1 ,不看也行,但是确保你对C++的模版特性有所了解。

    本文我一步一步带大家实现以下功能:

    1. 向量 (vec)

      • 有通用的模板定义和2D、3D的特化版本。

      • 通用构造函数,将每个元素初始化为T类型的默认值。

      • 2D和3D向量的构造函数可以接受特定的初始化值。

      • 提供了索引运算符来获取或设置特定元素的值。

      • 3D向量有norm函数,返回向量的模长。

      • 3D向量有normalize函数,可以规范化向量。

      • 重载了输出运算符,方便向量的打印。

      • 运算符重载:向量的点乘、加法、减法、标量乘法和标量除法。

      • embedproj函数用于扩展或投影向量到不同维度。

      • 3D向量之间的外积运算。

    2. 矩阵 (mat)

      • 可以获取或设置矩阵的行。

      • 获取矩阵的某一列。

      • 设置矩阵的某一列。

      • 获取单位矩阵。

      • 计算矩阵的行列式。

      • 获取矩阵的子矩阵。

      • 计算矩阵的余子式。

      • 计算伴随矩阵。

      • 计算逆矩阵的转置。

      • 运算符重载:矩阵和向量的乘法、两个矩阵的乘法、矩阵的标量除法。

      • 重载了输出运算符,方便矩阵的打印。

    3. 其他功能

      • 使用typedef定义了常用的类型,如Vec2f, Vec3i, Matrix等。

      • 在geometry.cpp中,提供了从3D和2D的float向量到int向量的转换,以及相反的转换。

    第二关:实现Vec2模版以及四个算数符

    这一关我们构建Vec2i和Vec2f类,以及实现他们的加、减、点积和叉积操作。

    我这里直接创建了一个新的cpp项目,名为MyMathLib。在项目中,创建 geometry.h 头文件和 geometry.cpp 源代码文件。提醒一下,其实在写类模版的时候尽量都把内容写在头文件里即可,这里只是暂时分开写,我们马上就会发现这种写法的维护难度很大。

    在头文件中定义 Vec2 模版类,让他既支持整形,也支持浮点数。

    源代码文件实现构造函数、加法、减法、点积和叉积,然后在最后实现外部模板实例化。

    注意:一般情况下,我们都直接把所有的实现(即函数体)都放在头文件中,这里只是稍微拓展一下可以使用外部模板实例化将类的模版的实现放在.cpp中。

    首先,我们需要理解C++中的模板是什么。模板不是实际的函数或类,而是编译器使用的蓝图,用于生成函数或类的特定版本。这就是为什么我们通常会看到模板的定义直接在头文件中:当模板在某个源文件中使用时,编译器需要看到完整的模板定义,以便为特定的类型生成正确的代码。

    为什么要实现外部模板实例化?模板的定义通常直接出现在头文件中。但有时,为了组织或其他原因,会把模板类的定义从其声明中分离出来(就像常规的非模板类那样),我目前也是这样做的。但这样做引发了一个问题,当链接器尝试链接对象文件时,如果它没有为特定的模板类型实例找到定义,就会出错。这是因为编译器只为那些它确实看到的模板类型生成代码。

    对于模板类,如果模板类的所有成员函数都在类声明中定义(即在头文件中定义),那么当模板类用于特定类型时,编译器可以立即为该类型生成模板类的实例。但是,如果模板类的某些成员函数在类声明之外定义(例如,在.cpp文件中),那么你可能需要使用外部模板实例化来确保为所需的类型生成正确的模板实例。

    调用测试一下。

    结果:

    v1 + v2 = (3, 5) v1 . v2 = 8 v1 x v2 = -1 v3 + v4 = (3, 5) v3 . v4 = 8 v3 x v4 = -1

    第三关:实现Vec3模版以及四个算数符

    Vec3其实和Vec2基本一致,只需修改一下计算代码即可,这里就不一一展示了。大部分就是将Vec2改成Vec3,计算时增加一个维度的考虑,比方说叉积。

    第四关:用模版构建不同大小的向量

    如果我还想添加Vec4,岂不是又要写一大堆,不简洁!对于多种不同大小的向量进行明确实例化是非常繁琐的。

    这里我是用的是 ... 折叠表达式(C++17)递归构造。目前头文件大致结构是这样的:

    这里说一下构造函数,如果是隐式构造的,那么默认会使用Vec()。为了代码健壮性,其实可以在带可变长参数的构造函数前加 explicit 关键词。

    读者可能感觉到了,我在这里直接将 .cpp 的操作挪过来头文件里边来实现了,这是因为如果这个时候要分离写的话,代码冗余量会很大。因此我们全部写在头文件里面,省事优雅。下面是当前完整的 geometry.h 代码。

    测试:

    (3, 5, 7) v3 \dot v4 = 20 v3 x v4 = (-1, 2, -1)

    第五关:进一步完善向量功能

    先总结一下目前完成的内容:

    目前来说已经基本可以用了,但是还有很多需要完善,我们继续看需要完成的内容!

    1. 增加标量与向量的乘/除法

    2. 计算向量的模

    3. 向量单位化

    4. 重载输出运算符

    另外,可以在声明运算操作中使用 [[nodiscard]] 标签,提醒编译器注意检查返回值是否得到使用,然后使用该库的用户就可以在编辑器中得到提醒,例如下面。

    当前功能的声明:

    对应的实现:

    可以在这里链接🔗中获取当前的向量库代码。

    第六关:构建矩阵

    有了上面构建向量模版的经验,我们可以照葫芦画瓢写出一个矩阵模版。矩阵的构造、访问元素、加法、乘法等操作都一一实现即可。

    这里读者应该给自己几个小时,独立写出代码。

    当我作为库的使用者创建一个矩阵时,我会想这样创建:

    构造函数可以使用两层嵌套的 std::initializer_list。其中,std::initializer_list 是一个C++11中引入的模板类,它表示编译时确定的值列表。先遍历行,再遍历列。

    这里重点说一下矩阵的乘法。

    一个 Rows x Cols 的矩阵A和一个 Cols x NewCols 的矩阵B相乘,那么结果将是一个 Rows x NewCols 的矩阵。举个例子:

    下面是其他的一些操作。

    总结一下目前的工作:

    添加了完整的注释供大家参考,可以在这个链接🔗中找到当前的数学库。

    第七关:继续完善矩阵库

    测试:

    输出:

    1 2 0 0 0 2 0 0 0 2 1 0 2 2 2 2

    现在我如果要取用 Matrix 的 mat 对象的数值,我们是这样的

    但是我想直接

    此时我们需要使用代理对象的设计模式:

    6.3 整合光栅化代码

    浏览我们目前的main函数,既有矩阵变换函数,也有视角变换函数,还有三角形重心坐标光栅化三角形的函数,更有视角变换矩阵等,真的有些乱。我们将这些方法打包到一个新的类里面,这个类称为:our_gl。

    特别节目1之:main代码之旅

    最终main函数如下:

    image-20230921171158181

    其中,顶点着色和片元着色是可编程的。可以参考当前的项目代码链接🔗

    🎬 开场白

    接下来开始解读这个main函数。首先,代码导入了一堆头文件,为了让我们的程序能够处理3D模型、向量计算和图像生成。

    🌍 全局变量来啦

    接着,全局变量闪亮登场!有了宽度、高度、光照方向、观察点等等,这简直是个小型的“宇宙”。

    🎭 GouraudShader 诞生

    然后,我们有一个名为 GouraudShader 的类,这家伙是渲染的明星!它的职责是处理顶点和片段(像素)。

    🎸 主舞台 main 函数

    最后,main() 函数,这是我们的主舞台。所有的预设、加载、渲染都在这里完成。

    🎥 Action!动作!

    1. 加载模型: new Model("../object/african_head/african_head.obj"); 这里,我们召唤了一个来自非洲的神秘头颅!

    2. 视角设置: 使用 lookat, viewport, 和 projection 函数,我们调整了观察点、视口和投影。这些都是电影导演级别的设置!

    3. 初始化画布: TGAImage image (width, height, TGAImage::RGB); 这里我们预备了一张画布,准备大展身手!

    4. 渲染循环: 嗯,这里有一个循环,负责画出那个非洲头颅。用了 GouraudShader,它会逐个面片地渲染模型。

    5. 图片翻转和保存: 最后,不要忘了翻转图像,并保存为 .tga 格式。现在你就可以拿这个图跟朋友炫耀了!

    特别节目2之:细说GouraudShader

    这个角色是渲染的灵魂,让我们细致入微地来看一下它的表演。

    🕺GouraudShader的组成

    这个变量是一个3D向量(Vec3f类型),用来存储每个顶点的光照强度。这里的“varying”意味着这个变量会在顶点着色器和片段着色器之间“变化”(实际上是插值)。

    这个函数负责处理每个顶点。它做了以下几件事:

    1. 获取模型顶点: Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert)); - 从3D模型中提取出一个顶点。

    2. 坐标转换: gl_Vertex = Viewport*Projection*ModelView*gl_Vertex; - 使用各种矩阵变换将这个顶点从模型空间转换到屏幕空间。

    3. 光照计算: varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert)*light_dir); - 根据光照方向计算这个顶点的光照强度。

    这个函数负责处理每个像素(片段):

    1. 插值计算: float intensity = varying_intensity*bar; - 这里使用bar来进行插值,得到当前像素的光照强度。

    2. 颜色设置: color = TGAColor(255, 255, 255)*intensity; - 根据光照强度设置像素的颜色。

    3. 像素保留: return false; - 表示这个像素不会被丢弃,将出现在最终的图像中。

    🎭角色分析

    这个GouraudShader类扮演了一个全能艺人的角色:

    image-20230921174128141

    特别节目3之:开始绘画-片元着色器

    OK,我们重新化妆一下,修改片元着色器(fragment)。

    根据光照强度对像素进行了“分级”。每个级别都有一个特定的光照强度,就像你在照片编辑软件里手动设置不同级别的亮度。

    这里将颜色设置为一个黄色(155, 155, 0),然后用上面的 intensity 来调节这个颜色。结果是一种不同深浅的黄色。

    image-20230921174422284

    着色器代码就像艺术家的调色板,你永远不知道接下来会画出什么样的图像!以下是一些有趣的着色器代码片段给大伙参考参考:

    🌈 彩虹着色器

    image-20230921193615375

    📺 模拟老电视效果

    image-20230921193810437

    🔥 火焰效果

    image-20230921193918227

    🌌 星空效果

    image-20230921193958547

    6.4 升级Shader-支持UV纹理

    在上一节我们把玩了GouraudShader,现在我介绍一个更强的选手,Shader,他不仅能够处理光照,还支持纹理贴图!

    image-20230921212841026

    🎬 Shader类的角色列表

    1. varying_intensity:这个角色没变,依然是顶点着色器计算出的光照强度。

    2. varying_uv:新角色登场!这家伙用来存储每个顶点的纹理坐标(u,v)。

    🎭 vertex函数:多面手

    这个函数的流程与之前的大同小异,但多了一个关键步骤:

    这里,它从模型中获取每个顶点的纹理坐标(UV坐标)并存储下来。这些坐标将被用于后面的片段着色器中进行纹理贴图。

    🌈 fragment函数:艺术家

    在这个函数里,除了处理光照之外,我们还添加了纹理:

    这里,bar用来进行插值,得到当前像素点的光照强度和纹理坐标。

    接着,最精彩的部分来了。我们将之前计算的 intensity 和从 uv 贴图中获取的颜色相乘,得到的就是一个非常真实的颜色了。

    🎨 那么,这个Shader类都能做什么?

    1. 纹理贴图:它能给3D模型穿上“衣服”,使模型看起来更逼真。

    2. 漫反射光照:它依然做好了基础的光照工作,让模型不会看起来像个平面。

    3. 代码复用:由于这个类继承了IShader,你可以很容易地在不同的渲染任务中复用这段代码。

    6.5 学习法线贴图

    法线贴图是一种用于3D计算机图形的技术,用于使3D模型的表面看起来更加详细,而无需使用更多的多边形。

    简而言之,你使用一张纹理来存储有关如何微妙调整模型表面上的法线向量的信息,从而改变光与模型表面的相互作用方式。

    第一关:纹理

    用于法线贴图的纹理通常看起来像一种奇怪、抽象的蓝色混合物。每个像素的RGB值代表一个3D向量的X、Y、Z分量,这将用于在光照计算期间调整3D模型的表面法线。这与仅依赖模型几何形状计算得出的法线(每个顶点的法线)有所不同。

    image-20230921214038676

    第二关:全局坐标系与Darboux坐标系

    image-20230921222119442

    通常,Darboux坐标系中的纹理看起来更加“有机”或“弯曲”,因为它是相对于物体表面的。全局坐标系中的纹理可能看起来更“统一”或“笔直”。因此,Darboux坐标系(切线空间)通常被认为更好,原因是它是对象相对的,使其在复杂的3D场景中具有更高的灵活性和通用性。

    image-20230921222407082

    第三关:经常见到的Uniform

    首先,“Uniform”是GLSL(图形库着色器语言)中的一个保留关键字。这个关键字让你能够将常量(不会在渲染过程中改变的值)传递到着色器中。这里我们的渲染器也保留GLSL的名字。

    就像给了演员一个剧本,告诉他们:"别乱改,按这个演!"

    第四关:光照计算

    在上面这段代码中,光照强度的计算与之前基本相同,但有一个例外:它不是从每个顶点插值得到法线向量,而是从法线贴图纹理中获取这些信息。

    换句话说,以前是依赖演员的自然演技(顶点法线),现在我们有了特效化妆师(法线贴图纹理)来让演员更出彩!

    简而言之,上面这段代码就是将业余戏剧社升级到好莱坞级别的制作!

    6.6 实现Phong模型

    Phong模型包含了三项漫反射 (Diffuse Reflection)镜面反射 (Specular Reflection)环境反射 (Ambient Reflection)

    image-20230525172542652

    L=La+Ld+Ls=kaIa+kd(I/r2)max(0,nl)+ks(I/r2)max(0,nh)p

    image-20230921234503261

    接下来,我们将会讨论阴影。

    7.1 阴影

    需要注意的是,这里我们谈论的是硬阴影,软阴影的实现又是另外一回事了。

    image-20230922113248506image-20230922113300470

    上面两张图片就是本章要实现的内容。读者可能会想,右边的图片拍摄角度是不是有问题。实际上,右边这张图的拍摄位置是光源所在的位置。至于为什么,我们就在本章详细探讨。你可能会看到上图有一些瑕疵,这正是 Z-fighting 现象。

    第一关:目前的问题

    回到我们上一章完成的进度。根据我们的常识,在光线照射不到的地方(图中高亮人物脖子的一侧),应该与能照射到的地方有比较明显的光照分界。目前我们的渲染器输出的效果是左图,而正常来说应该像右边的图片那样。

    image-20230922151625557image-20230922152552309

    为了解决这个问题,就要搬出图形学大名鼎鼎的 two-pass 方法(two-pass rendering)了。这个方法基本思想是先从光源处渲染一副有深度信息的图片,这张照片记录了从光源视角看到的深度信息。接下来再从主相机视角渲染图像,通过上一 Pass 的深度信息判断当前的渲染像素点时候直接被光照射。

    第二关:第一趟渲染-从光源出发

    image-20230922163751926

    第三关:第二趟渲染-从主相机出发

    效果非常好~该有的阴影都有了。但是我们注意这只怪物的手,阴影非常奇怪。

    image-20230922163743152

    奇怪的手臂阴影,这种现象我们称为阴影痤疮(Shadow Acne)。当渲染的物体与其阴影深度映射几乎重合时,可能会出现阴影斑点或噪声。

    image-20230922164007209

    怎么解决呢?可以考虑提高“阈值”,让物体没那么容易被相邻的部位遮挡住自己。具体到写代码上,就是稍微减小对应点的深度值。一点点小小的魔法,就可以解决这个烦人的问题了。

    image-20230922170825404

    项目的代码可以还是继续提供给读者们解读,链接🔗

    由于我比较懒,不想写太详细解读了,读者自己应该可以读懂。

    8.1 环境光遮蔽 - 模拟全局光照效果

    上一讲我们实现了 Phong 光照模型,他的组成有三项分别是环境光、高光以及漫反射。还讲了一种渲染策略,Two-Pass渲染。这里,我们介绍一种新的全局光照模拟技术,环境光遮蔽(Ambient Occlusion, AO)。

    但是,Phong模型只考虑了物体与特定光源之间的直接互动。在物体的小凹陷或接近的物体之间的接触区域,经常会出现微小的阴影。这些阴影往往与任何特定的光源无关,而是由于环境光被周围的几何体部分遮挡所造成的。Phong模型无法捕获这种效果,而AO可以。

    巧合的是,AO并不直接依赖于场景中的光源位置或属性。这使得它可以与任何光照模型(如Phong模型)结合使用,并为渲染效果增添额外的真实感。

    特别节目:知识脉络

    读者读到这里可能会有很多疑惑,对这些名词概念的层级把握不清楚,这里给读者梳理一下。

    1. 光照模型

      • Phong模型

        • 环境光

        • 漫反射光

        • 镜面反射光

      • Blinn-Phong模型

      • Lambert模型

      • Cook-Torrance模型

      • Oren-Nayar模型

    2. 全局光照模拟技术

      • 环境光遮蔽 (AO)

      • 光线追踪 (Ray Tracing)

      • 光子映射 (Photon Mapping)

      • 辐射度缓存 (Radiance Caching)

      • Final Gathering

    3. 渲染策略

      • Two-Pass渲染

        • Two-Pass阴影映射

      • 多Pass渲染

      • 延迟渲染 (Deferred Rendering)

      • 前向渲染 (Forward Rendering)

    4. 后处理效果

      • 色调映射 (Tone Mapping)

      • 抗锯齿技术 (如 MSAA, FXAA, TAA)

      • 深度模糊 (Depth of Field)

      • 动态模糊 (Motion Blur)

    5. 纹理技术

      • 传统纹理映射

      • 法线贴图 (Normal Mapping)

      • 抛物线映射 (Parallax Mapping)

      • 物理基础渲染 (Physically-Based Rendering, PBR) 的材质纹理(如 Albedo, Roughness, Metallic)

    第一关:啥是AO?如何结合Phong使用?

    环境光遮蔽的基本思想是评估一个给定的表面点在多大程度上被其周围的几何体遮挡。一个被其他物体严重遮挡的点会接收到更少的环境光,因此看起来会更暗。

    当你在场景中使用Phong模型和环境光遮蔽时,通常的方法是先计算Phong模型的环境反射组成部分,然后使用环境光遮蔽来调整这个值。具体来说,你会将环境光遮蔽值乘以Phong模型的环境光分量,从而在需要的地方减少环境光。

    计算方式有很多,最简单的是屏幕空间技术(如SSAO,Screen Space Ambient Occlusion)。但是在介绍这个方法之前,我们不妨先自行思考一下我们如何实现。

    第二关:做梦

    想象一下你正在拍摄一个物体,而这个物体上方有一个半透明的伞,伞的下半部分可以发出均匀的光。现在,为了知道物体的哪些部分更容易被这个光照亮,你决定在伞的内侧随机选择一些点,然后看看从这些点发出的光线能不能照到物体。

    它采用一种“暴力”的方法:随机选择很多点,并从每一个点观察物体。

    为了记录物体上哪些部分被光照到了,我们用一个图片来记录。每一次从伞的一个点看物体,都会产生一个新的图片。

    最后,我们把所有的图片混合在一起,得到一个平均的图片。这个图片会告诉我们,物体的哪些部分通常更容易被光照到。

    但是,这种方法也有缺点。比如,如果物体的两个手臂在最终的图片中使用了相同的位置,那么这两个手臂上的光就会被计算两次,这会导致最终的效果不准确。

    第三关:屏幕空间环境遮挡 (SSAO)

    全局照明非常昂贵,需要为很多点计算可见性。为了找到一个在计算时间和渲染质量之间的平衡,我们尝试使用SSAO。

    在这里我们将SSAO用作一个单独的效果,只计算环境遮挡而不计算其他光照。

    这个着色器主要用于渲染z-buffer,只关心深度,不关心颜色。

    估算一个像素点与其周围环境的最大仰角,这是评估遮挡程度的关键函数。

    对于每个像素,使用8个方向的射线来评估其环境遮挡程度。

    项目完整代码,链接🔗

    image-20230925172916074

    附录1. c++模版类 - 从入门到入土

    第一关:为什么需要模版类?

    第二关:「函数模版」

    第三关:「类模版」

    第四关:「多模板参数」与「非类型参数」

    第五关:「模板特化」

    第六关:「类型推断」

    1. auto & decltype 2. 模板中的基本类型推断3. 自动构造模版类型4. 尾返回类型

    第七关:「变量模板」

    第八关:「模板类型别名」

    第九关:模板的SFINAE原则

    第十关:模板与友元

    第十一关:折叠表达式

    第十二关:模板概念 - C++20

    第十三关: std::enable_if 和 SFINAE

    第十四关:类模板偏特化

    第十五关:constexpr 和模板

    第十六关:模板中的嵌套类型

    第十七关:模板参数包与展开

    第十八关:Lambda 表达式与模板

    第十九关:模板递归

    第二十关:带有模板的继承

    第一关:为什么需要模版类?

    在没有模板之前,如果你想为不同的数据类型编写相同的功能,你可能需要为每种数据类型写一个函数或类。这会导致大量的重复代码。

    用专业的话来说就是,函数模板和类模板在 C++ 中是用来支持泛型编程的工具。泛型编程是一种编写与类型无关的代码的方法。这就意味着,通过使用模板,你可以创建一个能够适应任何数据类型的函数或类,而不需要为每种数据类型都重新编写代码。

    例如一个函数,它的任务是交换两个整数的值。后来,你又想交换两个浮点数。没有模板,你可能需要为每种数据类型编写单独的函数。

    第二关:「函数模版」

    解决上面提到的问题,非常简单。

    template <typename T> 声明了一个模板函数。此处的 T 可以被认为是一个占位符,它在编译时会被实际的数据类型替换。

    第三关:「类模版」

    类模版跟函数模版差不多。下面的例子是一个用于存储任意类型的数组的类。

    第四关:「多模板参数」与「非类型参数」

    可以为一个模板定义多个参数。同时,参数可以是上面所说的 typename T 非类型参数,也可以是类型参数,像下面代码中的 int SIZE

    第五关:「模板特化」

    有时候,希望某个模板对某个特定类型有一个不同的实现。这时你可以使用模板特化。假如现在有下面的模版。

    但我希望对于 int 类型有一个特殊的输出。

    第六关:「类型推断」

    1. auto & decltype

    在 C++11 中引入了很多特性,其中一个与类型推断相关的特性是“auto”关键字。除了刚才说的“auto”,C++11还引入了“decltype”关键词,可以判断一个表达式的类型。

    2. 模板中的基本类型推断

    此外,函数模板的类型推断在 C++ 中已经存在了一段时间,但 C++11 增强了这一特性。函数模板可以自动推断类型参数。

    3. 自动构造模版类型

    在 C++17 之后,类型推断就更加强大了。在 C++17 之前,类模板的类型参数不能自动推断。但是从 C++17 开始,我们可以通过模板参数的自动类型推断来构造类模板的对象。

    4. 尾返回类型

    C++11 引入了尾返回类型,使得函数的返回类型可以基于其参数进行推断,这对于模板特别有用。下面代码的 -> 用于指定函数的尾返回类型。此时,auto 告诉编译器函数返回类型将由其后的表达式来决定,也就是刚刚说的 ->

    第七关:「变量模板」

    C++14 引入了变量模板,它允许你为模板定义静态数据成员。它与函数和类的模板类似,但是用于变量。

    我们定义了一个名为 pi 的变量模板,它为每种类型 T 提供了 π 的近似值。你可以像使用其他模板那样使用变量模板,但需要指定模板参数来获取相应的变量实例。

    一般这个「变量模版」非常适用于那些需要为不同类型提供不同值或配置的情况。同时使用的时候注意以下事项:

    第八关:「模板类型别名」

    「模板类型别名」为已存在的模板类型定义了一个新的、更简短的名称。

    在 C++11 之前,如果你想为复杂的模板类型创建别名,这往往是非常麻烦的。C++11 引入了 using 关键字来创建模板类型别名,这提供了一个更清晰、更简洁的方式来定义这些别名。

    这里以 第三关 的例子说明创建别名的最简单实践。

    这里再举一个简单、常用的例子为常见的向量类型提供别名。

    值得注意的是,你也可以用old school的方法,即typedef。上下两段代码是完全一致的。

    他们的区别在于,typedef 使用旧的 C/C++ 语法,而using 是 C++11 引入的新语法,用于定义类型别名。对于简单的类型别名,这两种方法之间的差异可能不明显。但是,当涉及到更复杂的类型,如函数指针或模板类型,using 的语法往往更为简洁和直观。

    这里拓展一下,usingtypedef 两者一个主要的区别是,using可以为模板提供别名。

    第九关:模板的SFINAE原则

    SFINAE 原则是 C++ 模板中的一个特性。SFINAE是“Substitution Failure Is Not An Error”(替换失败不是错误)的缩写。当试图用给定的模板参数替换模板时,如果发生错误,则该特殊化不被考虑。

    想象一下你正在为一个魔法展示准备一套卡片。每张卡片上都有一个指令,例如“变成兔子”或“飞起来”。但有一张卡片的指令是“让猪飞起来”。显然,这是一个不可能的任务。

    在通常情况下,魔术师会看到这张卡片并说:“这个指令有问题,展示失败了!”。但在 SFINAE 的世界里,魔术师会说:“好吧,这张卡片不工作,让我试试下一张”。

    换句话说,SFINAE 就像是编译器的一个内置魔术师。当你尝试用一个不合适的类型进行模板替换时,而不是直接报错,编译器会悄悄地“忽略”那个模板,并尝试其他的选项。

    直到没有选项合适(No matching)或者很多合适选项(Ambiguous),编译器就会报出错误。

    一个简单的场景:我们希望写一个函数 printValue,该函数可以打印整数或字符串。但是,如果我们尝试使用其他类型,这个函数就不应该存在。

    这一长串代码确实有点丑陋了,我们将代码拆开详细看看。

    1. 模板声明:

      声明了一个模板函数,其中 T 是一个待定的类型。你可以为 T 提供任何类型,比如 intdoublestd::string 等,但是函数的实际行为取决于你提供的类型。

    2. 返回类型:

      这段代码使用了两个主要的模板工具:std::enable_ifstd::is_integral

      • std::is_integral<T>::value 是一个类型特性,检查 T 是否是整数类型。如果是,它返回 true;否则返回 false

      • std::enable_if 是一个模板,它有一个嵌套的 type 成员,但这个成员只在给定的布尔表达式为 true 时存在。在这里,它检查前面的 std::is_integral<T>::value 是否为 true

      结合起来,这意味着:

      • 如果 T 是整数类型,函数的返回类型将是 void(因为 std::enable_if 的默认类型是 void)。

      • 如果 T 不是整数类型,由于 type 成员不存在,SFINAE 将阻止此函数模板被实例化,因此该版本的 printValue 函数将不可用。

    如果我想让当函数传入int类型时输出double类型,可以这样做:

    关键部分是 typename std::enable_if<std::is_same<T, int>::value, double>::type,这会检查 T 是否与 int 相同。如果是,它将产生类型 double。如果不是,该版本的 printValue 函数将由于 SFINAE 而不被考虑。

    有朋友可能会说,为什么不用多态呢?写这坨代码实在是太难看了,我用多态写那叫一个简洁:

    以下是一些常见的解释:

    1. 泛型编程: 使用模板,你可以为各种类型编写通用的代码,而不仅仅是那些你预先知道的类型。

    2. 类型约束: 通过 SFINAE 和其他模板技巧,你可以对哪些类型可以用于你的泛型代码施加更精细的约束。例如,你可能想要一个函数,它只接受具有某些成员函数的对象。

    3. 编译时优化: 由于模板在编译时实例化,编译器可以为每个特定的类型生成优化过的代码,这可能会导致更高的执行效率。

    4. 灵活性: 模板提供了更多的灵活性,例如模板元编程、模板特化等,允许更复杂和高效的编程技术。

    5. 类型透明性: 当使用模板时,原始类型信息在使用模板函数或类的地方保持不变。这与多态不同,其中类型信息可能会丢失,特别是在使用继承和虚函数时。

    随着进一步学习以及项目的接触,我们可以更加体会到这种编程方式的优缺点。

    第十关:模板与友元

    模板类或函数可以声明为另一个类或函数的友元。

    第十一关:折叠表达式

    C++17中的折叠表达式可以简化某些变长模板参数的操作。

    例如,要计算所有给定参数的总和:

    第十二关:模板概念(Concepts) - C++20

    C++20引入了模板的概念,允许你为模板参数指定更明确的约束。只有满足给定概念的类型才可以作为print函数的参数。

    比如说,

    这里是其他的一些特性:

    另外还可以通过其他方法检查“一个类型是否可以被输出流输出”。也就是在下面代码中,我们定义了一个Printable的conecpt,要满足这个概念,类型 T 必须满足 requires 表达式中的要求。

    其中, requires 表达式是与概念 (concepts) 相关的一种新特性,用于描述一个类型必须满足的要求。

    在这里,我们要求类型 T 必须支持一个操作,即:当你尝试将 t 输出到 std::cout 时,结果的类型必须是 std::ostream&。在 requires 表达式中,-> 符号被用于指定一个表达式的预期返回类型。

    另外注意,requires 表达式是在编译阶段处理的。

    第十三关: std::enable_if 和 SFINAE

    上面我们已经有所提及,当我们希望根据某种条件来决定是否生成模板函数或类时,std::enable_if非常有用。

    例如,假设你有一个函数,你只希望当传入的类型是整数时,它才存在:

    第十四关:类模板偏特化

    现在我们从头开始梳理一遍类模板。假设我们有以下基本模板。

    接下来,对类模板偏特化。假设我们想为第二个模板参数是指针类型的所有情况提供特化。这里的"偏"意味着我们不是为两个特定的类型提供特化,而是只为一个类型(这里是 T2)提供。

    需要注意的是,函数模板不支持偏特化,但可以通过重载来达到类似的效果。

    第十五关:constexpr 和模板

    constexpr 是 C++11 引入的关键字,它用于声明常量表达式,这些表达式在编译时就可以计算出结果。使用constexpr与模板一起可以在编译时生成高效的代码。譬如下面的例子。

    那么结合 constexpr 和模板的例子是啥样的?当 constexpr 与模板结合使用时,你可以为各种类型创建编译时函数或实体,它们将针对给定的类型进行优化,并在编译时生成结果。

    两者结合的优势很大,我这里列出两点:

    第十六关:模板中的嵌套类型

    一个模板可以在其内部定义另一个模板类:

    接下来,让我们给 OuterInner 类添加一些成员函数,使它们更具功能性。

    使用示例:

    进一步添加功能,在 Outer 类中定义一个函数,该函数接受一个 Inner 对象并与之交互。

    总之需要知道,外部类完全可以访问其内部类及其成员,但它需要拥有内部类的对象实例才能访问内部类的非静态成员。

    第十七关:模板参数包与展开

    当使用变长模板参数时,你可以使用模板参数包。使用...修饰的参数被称为参数包。

    如果要用多态来实现上面的效果,将会变得比较复杂。需要为每一种要输出的类型创建一个公共的基类并实现虚函数。然后为每种具体的类型实现一个子类。下面是用多态来实现的,可以看出模版参数包的优越性了吧。

    还记得 十一关 讲解的折叠表达式吗?折叠表达式是 C++17 引入的,是一种新的、更简洁的方式来展开参数包,并对其应用特定的运算。在 C++17 之前,当需要在模板中使用参数包的时候,通常需要使用某种机制对其进行展开。在 C++11 和 C++14 中,展开参数包通常涉及到递归的模板技巧。例如,

    而使用了折叠表达式,就不用涉及递归输出了,上下两则代码完全一致。

    第十八关:Lambda 表达式与模板

    进一步添加“概念”,以确保类型是可计算的。这里直接使用了std::is_arithmetic_v

    第十九关:模板递归

    模板递归是一种非常强大的技巧,但也需要谨慎使用,因为它可能导致编译时间增加和代码膨胀。

    在前面我们已经见识到了模版的强大。例如,计算阶乘或斐波那契数列,直接在编译期间就可以完成计算,减少运行时的计算量。

    第二十关:带有模板的继承

    类模板可以继承自其他类模板。下面是一个最简单的例子,我们逐渐完善他。

    1. 模版基类

    可以创建一个模板基类,使得不同的子类可以以不同的方式特化或使用这个基类。

    2. 模版子类

    可以使子类是模板,而基类不是。这样,就可以为基类定义一组行为,而子类则为这些行为提供具体的实现。

    3. 在模板类中继承模板基类

    子类和基类都可以是模板,这样你可以创建高度灵活和可重用的设计。

    第二十一关:std::type_trait的工具集

    <type_traits>头文件提供了一组用于类型检查和修改的模板,可以在编译时获取和操作类型的信息。

    以下是 std::type_traits 中一些常用的工具:

    1. 基础类型检查:

      • std::is_integral<T>: 检查T是否是一个整数类型。

      • std::is_floating_point<T>: 检查T是否是一个浮点类型。

      • std::is_arithmetic<T>: 检查T是否是算术类型(整数或浮点数)。

      • std::is_pointer<T>: 检查T是否是指针。

      • std::is_reference<T>: 检查T是否是引用。

      • std::is_array<T>: 检查T是否是数组。

      • std::is_enum<T>: 检查T是否是枚举类型。

    2. 类型关系检查:

      • std::is_same<T, U>: 检查两个类型是否完全相同。

      • std::is_base_of<Base, Derived>: 检查Base是否是Derived的基类。

      • std::is_convertible<T, U>: 检查类型T是否可以被隐式转换为U。

    3. 类型修改器:

      • std::remove_reference<T>: 去除引用,得到裸类型。

      • std::add_pointer<T>: 为类型T添加一个指针。

      • std::remove_pointer<T>: 去除指针。

      • std::remove_const<T>: 去除常量限定符。

      • std::add_const<T>: 添加常量限定符。

    4. 其他:

      • std::underlying_type<T>: 对于枚举类型T,得到对应的底层类型。

      • std::result_of<F(Args...)>: 对于函数类型F,返回它使用参数Args...调用时的返回类型。

    5. 辅助类型:

      • 对于上述的每个特性检查,都有一个对应的_v后缀的变量模板,如std::is_integral_v<T>,它直接返回bool值,这使得代码更简洁。

    第二十二关:模板与动态多态性

    尽管模板提供了一种静态多态性形式,但它们也可以与虚函数和动态多态性结合使用。


    备注/声明

    1. 本文使用的模型数据由 Vidar Rapp 提供。

    2. 本文框架基于https://github.com/ssloy/tinyrenderer